--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 95b90a707d698c14a2c1fcca3bf31f66ceca41eb
Parents : fe3c9cf
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-17T05:05:06-05:00
feat: implement post-install prompts for existing users with registry and UI integration and also fix webgl stuff
Changes
16 files changed, 1025 insertions(+), 37 deletions(-)
Diff
diff --git a/docs/agents/skills/contribution-registries/SKILL.md b/docs/agents/skills/contribution-registries/SKILL.md
index 9089b52a..a94c5519 100644
--- a/docs/agents/skills/contribution-registries/SKILL.md
+++ b/docs/agents/skills/contribution-registries/SKILL.md
@@ -11,13 +11,14 @@ Wire nav, tools, commands, settings search, and WebSocket events through registr
## Registries
-| Registry | Role |
-| ----------------------------------------- | -------------------------- |
-| `navRegistry.js` | Primary sidebar / nav |
-| `toolsRegistry.js` | Tools area entries |
-| `commandRegistry.js` | Command palette |
-| `settingsSectionRegistry.js` | Settings search / sections |
-| `wsEventRegistry.js` + `wsEventBridge.js` | Typed WS handlers |
+| Registry | Role |
+| ----------------------------------------- | ------------------------------------- |
+| `navRegistry.js` | Primary sidebar / nav |
+| `toolsRegistry.js` | Tools area entries |
+| `commandRegistry.js` | Command palette |
+| `settingsSectionRegistry.js` | Settings search / sections |
+| `wsEventRegistry.js` + `wsEventBridge.js` | Typed WS handlers |
+| `postInstallPromptRegistry.js` | Existing-user / after-install prompts |
Core boot registers once via `registerCoreContributions.js` and `core*Entries.js` siblings.
@@ -43,6 +44,18 @@ For a new badge:
4. Refresh the count from the right API or WebSocket event
5. Clear it when the user has actually seen the related UI
+## Post-install / existing-user prompts
+
+Use `postInstallPromptRegistry` + `PostInstallPromptHost` when you need to ask existing installs to do or acknowledge something after an upgrade.
+
+1. Add an entry to `corePostInstallPromptEntries.js` with a stable `id`, `revision`, and i18n `titleKey` (optional description and button keys).
+2. Register happens via `registerCoreContributions`.
+3. `App.vue` shows the next pending prompt after tutorial / Android storage upgrade and before changelog.
+4. To show the same prompt again later, bump `revision`. Users who dismissed an older revision are prompted again.
+5. Optional `shouldShow()` gates platform or feature conditions. Optional `onPrimary` / `onSecondary` run actions before dismiss.
+
+Seen revisions live in `localStorage` under `meshchatx.post_install_prompts_seen` via `postInstallPromptState.js`.
+
## Hard rules
- New top-level pages still need a route in `main.js` (see `page-toast-tests`). Registries cover discoverability and dispatch, not routing alone.
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 66866605..e04c1ee7 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index c2083f95..8ec401bd 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -507,6 +507,7 @@
variant="upgrade"
@completed="onAndroidStorageUpgradeCompleted"
/>
+ <PostInstallPromptHost ref="postInstallPromptHost" />
<!-- LXMF QR modal -->
<div
@@ -620,6 +621,7 @@ import IntegrityWarningModal from "./IntegrityWarningModal.vue";
import ChangelogModal from "./ChangelogModal.vue";
import TutorialModal from "./TutorialModal.vue";
import AndroidStorageChoicePrompt from "./AndroidStorageChoicePrompt.vue";
+import PostInstallPromptHost from "./PostInstallPromptHost.vue";
import AppShellBanners from "./layout/AppShellBanners.vue";
import KeyboardShortcuts from "../js/KeyboardShortcuts";
import ElectronUtils from "../js/ElectronUtils";
@@ -653,6 +655,7 @@ export default {
ChangelogModal,
TutorialModal,
AndroidStorageChoicePrompt,
+ PostInstallPromptHost,
AppShellBanners,
},
setup() {
@@ -1287,6 +1290,13 @@ export default {
}
return prompt.showUpgrade();
},
+ async maybeShowPostInstallPrompt() {
+ const host = this.$refs.postInstallPromptHost;
+ if (!host || typeof host.showNext !== "function") {
+ return false;
+ }
+ return host.showNext();
+ },
onAndroidStorageUpgradeCompleted() {
// prompt handles restart when user copies to external storage
},
@@ -1578,6 +1588,8 @@ export default {
this.$refs.tutorialModal.show();
} else if (this.maybeShowAndroidStorageUpgrade()) {
// upgrade prompt for existing internal-storage installs
+ } else if (await this.maybeShowPostInstallPrompt()) {
+ // registry prompts for existing users (bump revision to re-show)
} else if (
this.appInfo &&
this.appInfo.changelog_seen_version !== "999.999.999" &&
diff --git a/meshchatx/src/frontend/components/PostInstallPromptHost.vue b/meshchatx/src/frontend/components/PostInstallPromptHost.vue
new file mode 100644
index 00000000..1e88153e
--- /dev/null
+++ b/meshchatx/src/frontend/components/PostInstallPromptHost.vue
@@ -0,0 +1,174 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <AppUpdatePrompt
+ :model-value="visible"
+ :title="resolvedTitle"
+ :description="resolvedDescription"
+ :primary-label="resolvedPrimaryLabel"
+ :secondary-label="resolvedSecondaryLabel"
+ :busy="busy"
+ @update:model-value="onVisibleUpdate"
+ @primary="onPrimary"
+ @secondary="onSecondary"
+ />
+</template>
+
+<script>
+import AppUpdatePrompt from "./AppUpdatePrompt.vue";
+import { listPostInstallPromptsByPriority } from "../js/registries/postInstallPromptRegistry.js";
+import { markPromptSeen, shouldShowPrompt } from "../js/postInstallPromptState.js";
+
+export default {
+ name: "PostInstallPromptHost",
+ components: { AppUpdatePrompt },
+ emits: ["completed", "dismissed"],
+ data() {
+ return {
+ visible: false,
+ busy: false,
+ activeEntry: null,
+ };
+ },
+ computed: {
+ resolvedTitle() {
+ if (!this.activeEntry?.titleKey) {
+ return "";
+ }
+ return this.$t(this.activeEntry.titleKey);
+ },
+ resolvedDescription() {
+ if (!this.activeEntry?.descriptionKey) {
+ return "";
+ }
+ return this.$t(this.activeEntry.descriptionKey);
+ },
+ resolvedPrimaryLabel() {
+ const key = this.activeEntry?.primaryLabelKey || "common.continue";
+ return this.$t(key);
+ },
+ resolvedSecondaryLabel() {
+ if (!this.activeEntry?.secondaryLabelKey) {
+ return "";
+ }
+ return this.$t(this.activeEntry.secondaryLabelKey);
+ },
+ },
+ methods: {
+ /**
+ * Find and show the next pending registry prompt.
+ * @returns {Promise<boolean>} true if a prompt was opened
+ */
+ async showNext() {
+ if (this.visible) {
+ return true;
+ }
+ const pending = await this.findNextPending();
+ if (!pending) {
+ return false;
+ }
+ this.activeEntry = pending;
+ this.visible = true;
+ return true;
+ },
+ /**
+ * @returns {Promise<import('../js/registries/postInstallPromptRegistry.js').PostInstallPromptEntry | null>}
+ */
+ async findNextPending() {
+ for (const entry of listPostInstallPromptsByPriority()) {
+ if (!shouldShowPrompt(entry.id, entry.revision)) {
+ continue;
+ }
+ if (typeof entry.shouldShow === "function") {
+ try {
+ const ok = await entry.shouldShow();
+ if (!ok) {
+ continue;
+ }
+ } catch (e) {
+ console.error(`post-install prompt ${entry.id} shouldShow failed`, e);
+ continue;
+ }
+ }
+ return entry;
+ }
+ return null;
+ },
+ hide() {
+ this.visible = false;
+ this.activeEntry = null;
+ this.busy = false;
+ },
+ onVisibleUpdate(val) {
+ this.visible = val;
+ if (!val) {
+ this.$emit("dismissed");
+ this.activeEntry = null;
+ this.busy = false;
+ }
+ },
+ dismissActive() {
+ const entry = this.activeEntry;
+ if (entry) {
+ markPromptSeen(entry.id, entry.revision);
+ }
+ this.hide();
+ this.$emit("completed", { id: entry?.id, revision: entry?.revision });
+ },
+ async onPrimary() {
+ if (this.busy || !this.activeEntry) {
+ return;
+ }
+ this.busy = true;
+ try {
+ const entry = this.activeEntry;
+ let keepOpen = false;
+ if (typeof entry.onPrimary === "function") {
+ const result = await entry.onPrimary({ entry });
+ keepOpen = result === false;
+ }
+ if (keepOpen) {
+ return;
+ }
+ if (entry.dismissOnPrimary !== false) {
+ this.dismissActive();
+ } else {
+ this.hide();
+ this.$emit("completed", { id: entry.id, revision: entry.revision });
+ }
+ } catch (e) {
+ console.error("post-install prompt primary action failed", e);
+ } finally {
+ this.busy = false;
+ }
+ },
+ async onSecondary() {
+ if (this.busy || !this.activeEntry?.secondaryLabelKey) {
+ return;
+ }
+ this.busy = true;
+ try {
+ const entry = this.activeEntry;
+ let keepOpen = false;
+ if (typeof entry.onSecondary === "function") {
+ const result = await entry.onSecondary({ entry });
+ keepOpen = result === false;
+ }
+ if (keepOpen) {
+ return;
+ }
+ if (entry.dismissOnSecondary !== false) {
+ this.dismissActive();
+ } else {
+ this.hide();
+ this.$emit("completed", { id: entry.id, revision: entry.revision });
+ }
+ } catch (e) {
+ console.error("post-install prompt secondary action failed", e);
+ } finally {
+ this.busy = false;
+ }
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index 384e420e..c232e1ce 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -1680,6 +1680,7 @@ export default {
this.loadedNodesCount = 0;
this.currentBatch = 0;
this.totalBatches = 0;
+ this.scheduleIconQueue();
return;
}
@@ -1756,12 +1757,18 @@ export default {
}
const updates = [];
for (const nodeId of item.nodeIds) {
- if (this.nodes.get(nodeId)) {
+ if (this.webglEngine) {
+ updates.push({ id: nodeId, image: url });
+ } else if (this.nodes.get(nodeId)) {
updates.push({ id: nodeId, image: url });
}
}
if (updates.length > 0) {
- this.nodes.update(updates);
+ if (this.webglEngine) {
+ this.webglEngine.updateNodeImages(updates);
+ } else {
+ this.nodes.update(updates);
+ }
}
await yieldToMain();
}
diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGL.js b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
index 37816079..1fee38b8 100644
--- a/meshchatx/src/frontend/js/networkVisualiserWebGL.js
+++ b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
@@ -1,24 +1,38 @@
/**
* WebGL2 canvas renderer for MeshChatX network visualiser.
- * Draws instanced node discs and line edges from WASM float buffers.
+ * Draws instanced circular sprites (textured when available) and line edges.
*/
-const NODE_STRIDE = 8;
-const EDGE_STRIDE = 8;
+/** WASM / scene pack: x y size r g b a kind */
+export const SCENE_NODE_STRIDE = 8;
+/** Draw instance: x y size r g b a useTex u v */
+export const NODE_STRIDE = 10;
+export const EDGE_STRIDE = 8;
+
+const ATLAS_CELL = 64;
+const ATLAS_COLS = 16;
+const ATLAS_ROWS = 16;
+const ATLAS_CAPACITY = ATLAS_COLS * ATLAS_ROWS;
const NODE_VS = `#version 300 es
layout(location=0) in vec2 a_corner;
layout(location=1) in vec2 a_center;
layout(location=2) in float a_size;
layout(location=3) in vec4 a_color;
+layout(location=4) in float a_useTex;
+layout(location=5) in vec2 a_uvOrigin;
uniform vec2 u_resolution;
uniform vec2 u_camera;
uniform float u_zoom;
out vec4 v_color;
out vec2 v_uv;
+out float v_useTex;
+out vec2 v_uvOrigin;
void main() {
v_uv = a_corner;
v_color = a_color;
+ v_useTex = a_useTex;
+ v_uvOrigin = a_uvOrigin;
float r = max(a_size, 2.0);
vec2 world = a_center + a_corner * r;
vec2 screen = (world - u_camera) * u_zoom + u_resolution * 0.5;
@@ -32,12 +46,25 @@ const NODE_FS = `#version 300 es
precision mediump float;
in vec4 v_color;
in vec2 v_uv;
+in float v_useTex;
+in vec2 v_uvOrigin;
+uniform sampler2D u_atlas;
+uniform vec2 u_cellUv;
out vec4 outColor;
void main() {
float d = length(v_uv);
if (d > 1.0) discard;
float edge = smoothstep(1.0, 0.72, d);
- outColor = vec4(v_color.rgb, v_color.a * edge);
+ if (v_useTex > 0.5) {
+ vec2 local = v_uv * 0.5 + 0.5;
+ vec2 texUV = v_uvOrigin + local * u_cellUv;
+ vec4 tex = texture(u_atlas, texUV);
+ float a = tex.a * edge * v_color.a;
+ if (a < 0.01) discard;
+ outColor = vec4(tex.rgb, a);
+ } else {
+ outColor = vec4(v_color.rgb, v_color.a * edge);
+ }
}
`;
@@ -95,6 +122,49 @@ function link(gl, vsSrc, fsSrc) {
return prog;
}
+/**
+ * Merge WASM scene node packs with atlas UVs into draw instances.
+ * @param {Float32Array|null} sceneNodes SCENE_NODE_STRIDE
+ * @param {{useTex:number,u:number,v:number}[]} texMeta per-node
+ * @param {Float32Array} [dst] scratch with length >= count * NODE_STRIDE
+ * @returns {Float32Array} view of length count * NODE_STRIDE
+ */
+export function mergeSceneNodesWithTextures(sceneNodes, texMeta, dst) {
+ const count = sceneNodes && sceneNodes.length ? Math.floor(sceneNodes.length / SCENE_NODE_STRIDE) : 0;
+ const need = count * NODE_STRIDE;
+ const out = dst && dst.length >= need ? dst : new Float32Array(need);
+ for (let i = 0; i < count; i++) {
+ const s = i * SCENE_NODE_STRIDE;
+ const d = i * NODE_STRIDE;
+ out[d] = sceneNodes[s];
+ out[d + 1] = sceneNodes[s + 1];
+ out[d + 2] = sceneNodes[s + 2];
+ out[d + 3] = sceneNodes[s + 3];
+ out[d + 4] = sceneNodes[s + 4];
+ out[d + 5] = sceneNodes[s + 5];
+ out[d + 6] = sceneNodes[s + 6];
+ const meta = texMeta?.[i];
+ out[d + 7] = meta?.useTex ? 1 : 0;
+ out[d + 8] = meta?.u ?? 0;
+ out[d + 9] = meta?.v ?? 0;
+ }
+ return need === out.length ? out : out.subarray(0, need);
+}
+
+/**
+ * Atlas UV origin for a slot index.
+ * @param {number} slot
+ * @returns {{u:number,v:number}}
+ */
+export function atlasUvForSlot(slot) {
+ const col = slot % ATLAS_COLS;
+ const row = Math.floor(slot / ATLAS_COLS);
+ return {
+ u: col / ATLAS_COLS,
+ v: row / ATLAS_ROWS,
+ };
+}
+
/**
* @param {HTMLCanvasElement} canvas
* @returns {WebGL2RenderingContext|null}
@@ -114,6 +184,107 @@ export function tryCreateWebGL2Context(canvas) {
}
}
+/**
+ * @param {WebGL2RenderingContext} gl
+ */
+function createIconAtlas(gl) {
+ const width = ATLAS_COLS * ATLAS_CELL;
+ const height = ATLAS_ROWS * ATLAS_CELL;
+ const texture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
+
+ const urlToSlot = new Map();
+ const pending = new Map();
+ const freeSlots = [];
+ let nextSlot = 0;
+ const scratch = typeof document !== "undefined" ? document.createElement("canvas") : null;
+ if (scratch) {
+ scratch.width = ATLAS_CELL;
+ scratch.height = ATLAS_CELL;
+ }
+ const scratchCtx = scratch?.getContext?.("2d") || null;
+
+ function allocSlot() {
+ if (freeSlots.length > 0) return freeSlots.pop();
+ if (nextSlot >= ATLAS_CAPACITY) return null;
+ return nextSlot++;
+ }
+
+ function paintSlot(slot, source) {
+ if (!scratchCtx || !scratch) return;
+ scratchCtx.clearRect(0, 0, ATLAS_CELL, ATLAS_CELL);
+ const sw = source.width || source.videoWidth || ATLAS_CELL;
+ const sh = source.height || source.videoHeight || ATLAS_CELL;
+ const scale = Math.min(ATLAS_CELL / sw, ATLAS_CELL / sh);
+ const dw = Math.max(1, Math.floor(sw * scale));
+ const dh = Math.max(1, Math.floor(sh * scale));
+ const dx = Math.floor((ATLAS_CELL - dw) / 2);
+ const dy = Math.floor((ATLAS_CELL - dh) / 2);
+ scratchCtx.drawImage(source, dx, dy, dw, dh);
+ const col = slot % ATLAS_COLS;
+ const row = Math.floor(slot / ATLAS_COLS);
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, col * ATLAS_CELL, row * ATLAS_CELL, gl.RGBA, gl.UNSIGNED_BYTE, scratch);
+ }
+
+ function loadImage(url) {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.decoding = "async";
+ img.onload = () => resolve(img);
+ img.onerror = () => reject(new Error(`icon load failed: ${url}`));
+ img.src = url;
+ });
+ }
+
+ /**
+ * @param {string} url
+ * @returns {Promise<number|null>} slot index or null
+ */
+ async function ensure(url) {
+ if (!url || typeof url !== "string") return null;
+ if (urlToSlot.has(url)) return urlToSlot.get(url);
+ if (pending.has(url)) return pending.get(url);
+ const slot = allocSlot();
+ if (slot == null) return null;
+ const work = loadImage(url)
+ .then((img) => {
+ paintSlot(slot, img);
+ urlToSlot.set(url, slot);
+ pending.delete(url);
+ return slot;
+ })
+ .catch(() => {
+ pending.delete(url);
+ freeSlots.push(slot);
+ return null;
+ });
+ pending.set(url, work);
+ return work;
+ }
+
+ function destroy() {
+ gl.deleteTexture(texture);
+ urlToSlot.clear();
+ pending.clear();
+ }
+
+ return {
+ texture,
+ ensure,
+ uvForSlot: atlasUvForSlot,
+ cellUv: { x: 1 / ATLAS_COLS, y: 1 / ATLAS_ROWS },
+ destroy,
+ getSlot: (url) => urlToSlot.get(url) ?? null,
+ };
+}
+
/**
* @param {HTMLCanvasElement} canvas
* @param {WebGL2RenderingContext} gl
@@ -121,6 +292,7 @@ export function tryCreateWebGL2Context(canvas) {
export function createNetworkVisualiserWebGL(canvas, gl) {
const nodeProg = link(gl, NODE_VS, NODE_FS);
const edgeProg = link(gl, EDGE_VS, EDGE_FS);
+ const atlas = createIconAtlas(gl);
const nodeCornerBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, nodeCornerBuf);
@@ -136,18 +308,22 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, nodeInstanceBuf);
- // center xy
+ const strideBytes = NODE_STRIDE * 4;
gl.enableVertexAttribArray(1);
- gl.vertexAttribPointer(1, 2, gl.FLOAT, false, NODE_STRIDE * 4, 0);
+ gl.vertexAttribPointer(1, 2, gl.FLOAT, false, strideBytes, 0);
gl.vertexAttribDivisor(1, 1);
- // size
gl.enableVertexAttribArray(2);
- gl.vertexAttribPointer(2, 1, gl.FLOAT, false, NODE_STRIDE * 4, 8);
+ gl.vertexAttribPointer(2, 1, gl.FLOAT, false, strideBytes, 8);
gl.vertexAttribDivisor(2, 1);
- // rgba
gl.enableVertexAttribArray(3);
- gl.vertexAttribPointer(3, 4, gl.FLOAT, false, NODE_STRIDE * 4, 12);
+ gl.vertexAttribPointer(3, 4, gl.FLOAT, false, strideBytes, 12);
gl.vertexAttribDivisor(3, 1);
+ gl.enableVertexAttribArray(4);
+ gl.vertexAttribPointer(4, 1, gl.FLOAT, false, strideBytes, 28);
+ gl.vertexAttribDivisor(4, 1);
+ gl.enableVertexAttribArray(5);
+ gl.vertexAttribPointer(5, 2, gl.FLOAT, false, strideBytes, 32);
+ gl.vertexAttribDivisor(5, 1);
gl.bindVertexArray(null);
const edgeVao = gl.createVertexArray();
@@ -162,6 +338,8 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
const uNodeRes = gl.getUniformLocation(nodeProg, "u_resolution");
const uNodeCam = gl.getUniformLocation(nodeProg, "u_camera");
const uNodeZoom = gl.getUniformLocation(nodeProg, "u_zoom");
+ const uNodeAtlas = gl.getUniformLocation(nodeProg, "u_atlas");
+ const uNodeCellUv = gl.getUniformLocation(nodeProg, "u_cellUv");
const uEdgeRes = gl.getUniformLocation(edgeProg, "u_resolution");
const uEdgeCam = gl.getUniformLocation(edgeProg, "u_camera");
const uEdgeZoom = gl.getUniformLocation(edgeProg, "u_zoom");
@@ -211,7 +389,6 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
nodeCount = nodes && nodes.length ? Math.floor(nodes.length / NODE_STRIDE) : 0;
const edgeCount = edges && edges.length ? Math.floor(edges.length / EDGE_STRIDE) : 0;
- // Expand edges to 2 verts * (xy + rgba) = 12 floats per edge -> 6 floats per vertex
const need = edgeCount * 12;
if (edgeScratch.length < need) {
edgeScratch = new Float32Array(Math.max(need, 64));
@@ -260,6 +437,10 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
gl.uniform2f(uNodeRes, size.width, size.height);
gl.uniform2f(uNodeCam, camX, camY);
gl.uniform1f(uNodeZoom, zoom);
+ gl.uniform1i(uNodeAtlas, 0);
+ gl.uniform2f(uNodeCellUv, atlas.cellUv.x, atlas.cellUv.y);
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, atlas.texture);
gl.bindVertexArray(nodeVao);
gl.bindBuffer(gl.ARRAY_BUFFER, nodeInstanceBuf);
gl.bufferData(gl.ARRAY_BUFFER, nodes, gl.DYNAMIC_DRAW);
@@ -271,6 +452,7 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
}
function destroy() {
+ atlas.destroy();
gl.deleteBuffer(nodeCornerBuf);
gl.deleteBuffer(nodeInstanceBuf);
gl.deleteBuffer(edgeBuf);
@@ -280,7 +462,15 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
gl.deleteProgram(edgeProg);
}
- return { draw, resize, destroy, getCssSize: () => ({ width: cssW, height: cssH }) };
+ return {
+ draw,
+ resize,
+ destroy,
+ ensureIcon: (url) => atlas.ensure(url),
+ iconUv: (slot) => atlas.uvForSlot(slot),
+ getIconSlot: (url) => atlas.getSlot(url),
+ getCssSize: () => ({ width: cssW, height: cssH }),
+ };
}
-export { NODE_STRIDE, EDGE_STRIDE };
+export { ATLAS_CELL, ATLAS_COLS, ATLAS_ROWS };
diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js
index b5b1cd38..113fec18 100644
--- a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js
+++ b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js
@@ -4,7 +4,13 @@
*/
import { callVisualiserWasmJson, isVisualiserWebGLSceneReady } from "./VisualiserWasmLoader.js";
-import { createNetworkVisualiserWebGL, tryCreateWebGL2Context } from "./networkVisualiserWebGL.js";
+import {
+ createNetworkVisualiserWebGL,
+ mergeSceneNodesWithTextures,
+ NODE_STRIDE,
+ SCENE_NODE_STRIDE,
+ tryCreateWebGL2Context,
+} from "./networkVisualiserWebGL.js";
export { isVisualiserWebGLSceneReady };
@@ -15,9 +21,24 @@ export const KIND_PEER = 3;
export const KIND_DISCOVERED = 4;
/**
- * True when WASM scene exports needed for WebGL path are present.
- * Re-exported from VisualiserWasmLoader for callers that import the engine module.
+ * Distance between two CSS points.
+ * @param {{x:number,y:number}} a
+ * @param {{x:number,y:number}} b
*/
+export function pointerDistance(a, b) {
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ return Math.hypot(dx, dy);
+}
+
+/**
+ * Midpoint of two CSS points.
+ * @param {{x:number,y:number}} a
+ * @param {{x:number,y:number}} b
+ */
+export function pointerMidpoint(a, b) {
+ return { x: (a.x + b.x) * 0.5, y: (a.y + b.y) * 0.5 };
+}
/**
* @param {HTMLCanvasElement} [_canvas] optional host (capability is global)
@@ -92,7 +113,6 @@ function kindForNode(node) {
function sizeForNode(node, kind) {
const s = Number(node?.size);
if (Number.isFinite(s) && s > 0) {
- // vis sizes are large; scale down for disc radius in world units
return Math.max(6, Math.min(28, s * 0.35));
}
if (kind === KIND_ME) return 18;
@@ -172,14 +192,24 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
const renderer = createNetworkVisualiserWebGL(canvas, gl);
const metaById = new Map();
+ const indexById = new Map();
+ /** @type {(string|null)[]} */
+ let imageByIndex = [];
+ /** @type {{useTex:number,u:number,v:number}[]} */
+ let texMeta = [];
+ let drawNodeScratch = new Float32Array(0);
let rafId = null;
let running = true;
let dirty = true;
- let pointerMode = null; // "pan" | "drag" | null
+ let pointerMode = null;
let lastX = 0;
let lastY = 0;
let nodeCount = 0;
let edgeCount = 0;
+ /** @type {Map<number,{x:number,y:number}>} */
+ const pointers = new Map();
+ let pinchLastDist = 0;
+ let iconLoadGen = 0;
function cssPoint(ev) {
const rect = canvas.getBoundingClientRect();
@@ -200,20 +230,48 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
}
}
+ function rebuildTexMeta() {
+ texMeta = imageByIndex.map((url) => {
+ if (!url) return { useTex: 0, u: 0, v: 0 };
+ const slot = renderer.getIconSlot(url);
+ if (slot == null) return { useTex: 0, u: 0, v: 0 };
+ const uv = renderer.iconUv(slot);
+ return { useTex: 1, u: uv.u, v: uv.v };
+ });
+ }
+
+ async function loadIconsForCurrentGraph(generation) {
+ const urls = [...new Set(imageByIndex.filter(Boolean))];
+ await Promise.all(
+ urls.map(async (url) => {
+ await renderer.ensureIcon(url);
+ })
+ );
+ if (!running || generation !== iconLoadGen) return;
+ rebuildTexMeta();
+ dirty = true;
+ }
+
function setGraph(graphNodes, graphEdges, viewOpts = {}) {
metaById.clear();
+ indexById.clear();
+ imageByIndex = [];
+ let idx = 0;
for (const n of graphNodes || []) {
if (!n?.id) continue;
- metaById.set(String(n.id), {
- id: String(n.id),
+ const id = String(n.id);
+ metaById.set(id, {
+ id,
label: n.label || "",
title: n.title || "",
group: n.group || "",
announce: n._announce || null,
});
+ indexById.set(id, idx);
+ imageByIndex[idx] = typeof n.image === "string" && n.image ? n.image : null;
+ idx += 1;
}
const size = renderer.resize();
- // zoom <= 0 keeps the current WASM camera (see scene.Set).
const preserveCamera = viewOpts.preserveCamera !== false && nodeCount > 0;
const req = graphToSceneRequest(graphNodes, graphEdges, {
width: size.width,
@@ -228,7 +286,29 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
}
nodeCount = got.nodes || 0;
edgeCount = got.edges || 0;
+ rebuildTexMeta();
dirty = true;
+ iconLoadGen += 1;
+ void loadIconsForCurrentGraph(iconLoadGen);
+ }
+
+ /**
+ * Apply deferred LXMF / custom icon URLs after paint.
+ * @param {{id:string,image:string}[]} updates
+ */
+ function updateNodeImages(updates) {
+ let changed = false;
+ for (const u of updates || []) {
+ if (!u?.id || !u?.image) continue;
+ const i = indexById.get(String(u.id));
+ if (i == null) continue;
+ if (imageByIndex[i] === u.image) continue;
+ imageByIndex[i] = u.image;
+ changed = true;
+ }
+ if (!changed) return;
+ iconLoadGen += 1;
+ void loadIconsForCurrentGraph(iconLoadGen);
}
function getPositions() {
@@ -261,17 +341,40 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
zoom: buf.zoom > 0 ? buf.zoom : 1,
};
const dark = typeof hooks.isDark === "function" ? hooks.isDark() : false;
- const size = renderer.draw(buf.nodes, buf.edges, camera, dark);
+ const sceneCount = buf.nodes && buf.nodes.length ? Math.floor(buf.nodes.length / SCENE_NODE_STRIDE) : 0;
+ const need = sceneCount * NODE_STRIDE;
+ if (drawNodeScratch.length < need) {
+ drawNodeScratch = new Float32Array(need);
+ }
+ const drawNodes = mergeSceneNodesWithTextures(buf.nodes, texMeta, drawNodeScratch);
+ const size = renderer.draw(drawNodes, buf.edges, camera, dark);
callScene("meshchatxVisualiserSceneResize", size.width, size.height);
nodeCount = buf.nodeCount || nodeCount;
edgeCount = buf.edgeCount || edgeCount;
dirty = false;
}
+ function activePointerPair() {
+ if (pointers.size < 2) return null;
+ const pts = [...pointers.values()];
+ return { a: pts[0], b: pts[1] };
+ }
+
function onPointerDown(ev) {
- if (ev.button !== 0) return;
+ if (ev.pointerType === "mouse" && ev.button !== 0) return;
canvas.setPointerCapture?.(ev.pointerId);
const p = cssPoint(ev);
+ pointers.set(ev.pointerId, p);
+ if (pointers.size >= 2) {
+ if (pointerMode === "drag") {
+ callScene("meshchatxVisualiserSceneDragEnd");
+ }
+ pointerMode = "pinch";
+ const pair = activePointerPair();
+ pinchLastDist = pair ? pointerDistance(pair.a, pair.b) : 0;
+ dirty = true;
+ return;
+ }
lastX = p.x;
lastY = p.y;
const id = callScene("meshchatxVisualiserScenePick", p.x, p.y, 16);
@@ -286,6 +389,23 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
function onPointerMove(ev) {
const p = cssPoint(ev);
+ if (pointers.has(ev.pointerId)) {
+ pointers.set(ev.pointerId, p);
+ }
+ if (pointerMode === "pinch") {
+ const pair = activePointerPair();
+ if (!pair || pinchLastDist <= 0) return;
+ const dist = pointerDistance(pair.a, pair.b);
+ if (dist <= 0) return;
+ const factor = dist / pinchLastDist;
+ if (Math.abs(factor - 1) > 0.001) {
+ const mid = pointerMidpoint(pair.a, pair.b);
+ callScene("meshchatxVisualiserSceneZoomAt", mid.x, mid.y, factor);
+ pinchLastDist = dist;
+ dirty = true;
+ }
+ return;
+ }
if (pointerMode === "drag") {
callScene("meshchatxVisualiserSceneDragTo", p.x, p.y);
dirty = true;
@@ -305,16 +425,34 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
}
function onPointerUp(ev) {
- if (pointerMode === "drag") {
- callScene("meshchatxVisualiserSceneDragEnd");
- }
- pointerMode = null;
- dirty = true;
+ pointers.delete(ev.pointerId);
try {
canvas.releasePointerCapture?.(ev.pointerId);
} catch {
/* ignore */
}
+ if (pointerMode === "pinch") {
+ if (pointers.size >= 2) {
+ const pair = activePointerPair();
+ pinchLastDist = pair ? pointerDistance(pair.a, pair.b) : 0;
+ } else if (pointers.size === 1) {
+ const remaining = [...pointers.values()][0];
+ lastX = remaining.x;
+ lastY = remaining.y;
+ pointerMode = "pan";
+ } else {
+ pointerMode = null;
+ }
+ dirty = true;
+ return;
+ }
+ if (pointerMode === "drag") {
+ callScene("meshchatxVisualiserSceneDragEnd");
+ }
+ if (pointers.size === 0) {
+ pointerMode = null;
+ }
+ dirty = true;
}
function onDblClick(ev) {
@@ -353,6 +491,7 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
function destroy() {
running = false;
+ iconLoadGen += 1;
if (rafId != null) cancelAnimationFrame(rafId);
rafId = null;
canvas.removeEventListener("pointerdown", onPointerDown);
@@ -362,12 +501,17 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
canvas.removeEventListener("dblclick", onDblClick);
canvas.removeEventListener("wheel", onWheel);
window.removeEventListener("resize", onResize);
+ pointers.clear();
renderer.destroy();
metaById.clear();
+ indexById.clear();
+ imageByIndex = [];
+ texMeta = [];
}
return {
setGraph,
+ updateNodeImages,
getPositions,
getCounts,
setLiveLayout,
diff --git a/meshchatx/src/frontend/js/postInstallPromptState.js b/meshchatx/src/frontend/js/postInstallPromptState.js
new file mode 100644
index 00000000..8466cb18
--- /dev/null
+++ b/meshchatx/src/frontend/js/postInstallPromptState.js
@@ -0,0 +1,103 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Persist which post-install / existing-user prompts have been dismissed.
+ * Each prompt has an id and a revision. Bump the revision in the registry
+ * to show that prompt again to users who already dismissed an older revision.
+ */
+
+export const POST_INSTALL_PROMPTS_STORAGE_KEY = "meshchatx.post_install_prompts_seen";
+
+/**
+ * @returns {Record<string, number>}
+ */
+export function readSeenMap() {
+ if (typeof window === "undefined" || !window.localStorage) {
+ return {};
+ }
+ try {
+ const raw = window.localStorage.getItem(POST_INSTALL_PROMPTS_STORAGE_KEY);
+ if (!raw) {
+ return {};
+ }
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return {};
+ }
+ /** @type {Record<string, number>} */
+ const out = {};
+ for (const [id, revision] of Object.entries(parsed)) {
+ const n = Number(revision);
+ if (Number.isFinite(n) && n >= 0) {
+ out[id] = Math.floor(n);
+ }
+ }
+ return out;
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * @param {Record<string, number>} map
+ */
+export function writeSeenMap(map) {
+ if (typeof window === "undefined" || !window.localStorage) {
+ return;
+ }
+ window.localStorage.setItem(POST_INSTALL_PROMPTS_STORAGE_KEY, JSON.stringify(map || {}));
+}
+
+/**
+ * @param {string} id
+ * @returns {number}
+ */
+export function getSeenRevision(id) {
+ if (!id) {
+ return 0;
+ }
+ const map = readSeenMap();
+ const n = Number(map[id]);
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
+}
+
+/**
+ * @param {string} id
+ * @param {number} revision
+ */
+export function markPromptSeen(id, revision) {
+ if (!id) {
+ return;
+ }
+ const nextRevision = Math.max(0, Math.floor(Number(revision) || 0));
+ const map = readSeenMap();
+ const prev = getSeenRevision(id);
+ if (nextRevision <= prev) {
+ return;
+ }
+ map[id] = nextRevision;
+ writeSeenMap(map);
+}
+
+/**
+ * @param {string} id
+ * @param {number} revision
+ * @returns {boolean}
+ */
+export function shouldShowPrompt(id, revision) {
+ const target = Math.max(0, Math.floor(Number(revision) || 0));
+ if (!id || target <= 0) {
+ return false;
+ }
+ return getSeenRevision(id) < target;
+}
+
+/**
+ * Test helper.
+ */
+export function clearPromptSeenState() {
+ if (typeof window === "undefined" || !window.localStorage) {
+ return;
+ }
+ window.localStorage.removeItem(POST_INSTALL_PROMPTS_STORAGE_KEY);
+}
diff --git a/meshchatx/src/frontend/js/registries/corePostInstallPromptEntries.js b/meshchatx/src/frontend/js/registries/corePostInstallPromptEntries.js
new file mode 100644
index 00000000..8462d44d
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/corePostInstallPromptEntries.js
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Core post-install / existing-user prompts.
+ *
+ * To re-prompt users who already dismissed a prompt, bump `revision`.
+ * Add entries here and register them via registerCoreContributions.
+ *
+ * @type {import('./postInstallPromptRegistry.js').PostInstallPromptEntry[]}
+ */
+export const CORE_POST_INSTALL_PROMPT_ENTRIES = [
+ // Example:
+ // {
+ // id: "example_notice",
+ // revision: 1,
+ // priority: 10,
+ // titleKey: "post_install.example_title",
+ // descriptionKey: "post_install.example_desc",
+ // primaryLabelKey: "common.got_it",
+ // },
+];
diff --git a/meshchatx/src/frontend/js/registries/postInstallPromptRegistry.js b/meshchatx/src/frontend/js/registries/postInstallPromptRegistry.js
new file mode 100644
index 00000000..5093ae52
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/postInstallPromptRegistry.js
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: 0BSD
+
+import { createRegistry } from "./registryCore.js";
+
+/**
+ * @typedef {Object} PostInstallPromptEntry
+ * @property {string} id
+ * Stable prompt id. Do not rename casually.
+ * @property {number} revision
+ * Monotonic. Bump to re-prompt users who dismissed an older revision.
+ * @property {string} titleKey
+ * i18n key for the dialog title.
+ * @property {string} [descriptionKey]
+ * i18n key for the body text.
+ * @property {string} [primaryLabelKey]
+ * i18n key for the primary button. Defaults to common.continue.
+ * @property {string} [secondaryLabelKey]
+ * i18n key for the secondary button. Omit for primary-only.
+ * @property {number} [priority]
+ * Higher runs first among pending prompts. Default 0.
+ * @property {() => boolean | Promise<boolean>} [shouldShow]
+ * Extra gate after revision check. Return false to skip.
+ * @property {(ctx: { entry: PostInstallPromptEntry }) => boolean | void | Promise<boolean | void>} [onPrimary]
+ * Return false to keep the dialog open and skip dismiss.
+ * @property {(ctx: { entry: PostInstallPromptEntry }) => boolean | void | Promise<boolean | void>} [onSecondary]
+ * Return false to keep the dialog open and skip dismiss.
+ * @property {boolean} [dismissOnPrimary]
+ * Mark seen after a successful primary action. Default true.
+ * @property {boolean} [dismissOnSecondary]
+ * Mark seen after a successful secondary action. Default true.
+ */
+
+/** @type {import('./registryCore.js').Registry<PostInstallPromptEntry>} */
+export const postInstallPromptRegistry = createRegistry("postInstallPromptRegistry");
+
+/**
+ * @param {PostInstallPromptEntry} entry
+ */
+export function registerPostInstallPrompt(entry) {
+ if (!entry?.id) {
+ throw new Error("postInstallPromptRegistry: entry requires an id");
+ }
+ const revision = Number(entry.revision);
+ if (!Number.isFinite(revision) || revision < 1) {
+ throw new Error(`postInstallPromptRegistry: entry "${entry.id}" requires revision >= 1`);
+ }
+ if (!entry.titleKey) {
+ throw new Error(`postInstallPromptRegistry: entry "${entry.id}" requires titleKey`);
+ }
+ postInstallPromptRegistry.register({
+ ...entry,
+ revision: Math.floor(revision),
+ priority: Number.isFinite(Number(entry.priority)) ? Number(entry.priority) : 0,
+ dismissOnPrimary: entry.dismissOnPrimary !== false,
+ dismissOnSecondary: entry.dismissOnSecondary !== false,
+ });
+}
+
+/**
+ * @param {string} id
+ */
+export function unregisterPostInstallPrompt(id) {
+ postInstallPromptRegistry.unregister(id);
+}
+
+/**
+ * @returns {PostInstallPromptEntry[]}
+ */
+export function listPostInstallPrompts() {
+ return postInstallPromptRegistry.list();
+}
+
+/**
+ * Highest priority first, then id for stability.
+ * @returns {PostInstallPromptEntry[]}
+ */
+export function listPostInstallPromptsByPriority() {
+ return listPostInstallPrompts()
+ .slice()
+ .sort((a, b) => {
+ const pa = Number(a.priority) || 0;
+ const pb = Number(b.priority) || 0;
+ if (pb !== pa) {
+ return pb - pa;
+ }
+ return String(a.id).localeCompare(String(b.id));
+ });
+}
diff --git a/meshchatx/src/frontend/js/registries/registerCoreContributions.js b/meshchatx/src/frontend/js/registries/registerCoreContributions.js
index 62e10451..1a5ecaf5 100644
--- a/meshchatx/src/frontend/js/registries/registerCoreContributions.js
+++ b/meshchatx/src/frontend/js/registries/registerCoreContributions.js
@@ -8,6 +8,8 @@ import { CORE_COMMAND_ENTRIES } from "./coreCommandEntries.js";
import { registerCommand } from "./commandRegistry.js";
import { CORE_SETTINGS_SECTION_KEYWORDS } from "./coreSettingsSectionKeywords.js";
import { registerSettingsSection } from "./settingsSectionRegistry.js";
+import { CORE_POST_INSTALL_PROMPT_ENTRIES } from "./corePostInstallPromptEntries.js";
+import { registerPostInstallPrompt } from "./postInstallPromptRegistry.js";
let coreRegistered = false;
@@ -36,4 +38,8 @@ export function registerCoreContributions() {
for (const [sectionId, keywords] of Object.entries(CORE_SETTINGS_SECTION_KEYWORDS)) {
registerSettingsSection({ id: sectionId, keywords });
}
+
+ for (const entry of CORE_POST_INSTALL_PROMPT_ENTRIES) {
+ registerPostInstallPrompt(entry);
+ }
}
diff --git a/tests/frontend/AppModals.test.js b/tests/frontend/AppModals.test.js
index e48ecccb..96e0b3b1 100644
--- a/tests/frontend/AppModals.test.js
+++ b/tests/frontend/AppModals.test.js
@@ -124,6 +124,7 @@ describe("App.vue Modals", () => {
CallOverlay: true,
CommandPalette: true,
IntegrityWarningModal: true,
+ PostInstallPromptHost: true,
// Stub all Vuetify components
VDialog: true,
VCard: true,
@@ -184,6 +185,7 @@ describe("App.vue Modals", () => {
CallOverlay: true,
CommandPalette: true,
IntegrityWarningModal: true,
+ PostInstallPromptHost: true,
// Stub all Vuetify components
VDialog: true,
VCard: true,
diff --git a/tests/frontend/PostInstallPrompt.test.js b/tests/frontend/PostInstallPrompt.test.js
new file mode 100644
index 00000000..16e74f32
--- /dev/null
+++ b/tests/frontend/PostInstallPrompt.test.js
@@ -0,0 +1,166 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mount } from "@vue/test-utils";
+import { createI18n } from "vue-i18n";
+import { createVuetify } from "vuetify";
+import PostInstallPromptHost from "../../meshchatx/src/frontend/components/PostInstallPromptHost.vue";
+import {
+ clearPromptSeenState,
+ getSeenRevision,
+ markPromptSeen,
+ shouldShowPrompt,
+} from "../../meshchatx/src/frontend/js/postInstallPromptState.js";
+import {
+ postInstallPromptRegistry,
+ registerPostInstallPrompt,
+ listPostInstallPromptsByPriority,
+} from "../../meshchatx/src/frontend/js/registries/postInstallPromptRegistry.js";
+
+const i18n = createI18n({
+ legacy: false,
+ locale: "en",
+ messages: {
+ en: {
+ common: { continue: "Continue" },
+ post_install: {
+ demo_title: "Demo title",
+ demo_desc: "Demo body",
+ demo_primary: "Got it",
+ demo_secondary: "Later",
+ },
+ },
+ },
+});
+const vuetify = createVuetify();
+
+describe("postInstallPromptState", () => {
+ beforeEach(() => {
+ clearPromptSeenState();
+ });
+
+ it("shows until revision is marked seen", () => {
+ expect(shouldShowPrompt("demo", 1)).toBe(true);
+ markPromptSeen("demo", 1);
+ expect(shouldShowPrompt("demo", 1)).toBe(false);
+ expect(getSeenRevision("demo")).toBe(1);
+ });
+
+ it("re-shows when revision is bumped", () => {
+ markPromptSeen("demo", 1);
+ expect(shouldShowPrompt("demo", 2)).toBe(true);
+ markPromptSeen("demo", 2);
+ expect(shouldShowPrompt("demo", 2)).toBe(false);
+ });
+});
+
+describe("postInstallPromptRegistry", () => {
+ beforeEach(() => {
+ postInstallPromptRegistry.clear();
+ });
+
+ it("orders by priority then id", () => {
+ registerPostInstallPrompt({
+ id: "b_low",
+ revision: 1,
+ priority: 1,
+ titleKey: "post_install.demo_title",
+ });
+ registerPostInstallPrompt({
+ id: "a_high",
+ revision: 1,
+ priority: 10,
+ titleKey: "post_install.demo_title",
+ });
+ registerPostInstallPrompt({
+ id: "c_high",
+ revision: 1,
+ priority: 10,
+ titleKey: "post_install.demo_title",
+ });
+ expect(listPostInstallPromptsByPriority().map((e) => e.id)).toEqual(["a_high", "c_high", "b_low"]);
+ });
+
+ it("rejects revision below 1", () => {
+ expect(() =>
+ registerPostInstallPrompt({
+ id: "bad",
+ revision: 0,
+ titleKey: "post_install.demo_title",
+ })
+ ).toThrow(/revision/);
+ });
+});
+
+describe("PostInstallPromptHost", () => {
+ beforeEach(() => {
+ clearPromptSeenState();
+ postInstallPromptRegistry.clear();
+ });
+
+ afterEach(() => {
+ clearPromptSeenState();
+ postInstallPromptRegistry.clear();
+ });
+
+ it("showNext opens the highest priority pending prompt", async () => {
+ registerPostInstallPrompt({
+ id: "low",
+ revision: 1,
+ priority: 1,
+ titleKey: "post_install.demo_title",
+ descriptionKey: "post_install.demo_desc",
+ primaryLabelKey: "post_install.demo_primary",
+ });
+ registerPostInstallPrompt({
+ id: "high",
+ revision: 1,
+ priority: 50,
+ titleKey: "post_install.demo_title",
+ descriptionKey: "post_install.demo_desc",
+ primaryLabelKey: "post_install.demo_primary",
+ });
+
+ const wrapper = mount(PostInstallPromptHost, {
+ global: { plugins: [i18n, vuetify] },
+ });
+ expect(await wrapper.vm.showNext()).toBe(true);
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.visible).toBe(true);
+ expect(wrapper.vm.activeEntry?.id).toBe("high");
+ });
+
+ it("primary dismisses and marks the revision seen", async () => {
+ const onPrimary = vi.fn();
+ registerPostInstallPrompt({
+ id: "once",
+ revision: 3,
+ titleKey: "post_install.demo_title",
+ primaryLabelKey: "post_install.demo_primary",
+ onPrimary,
+ });
+
+ const wrapper = mount(PostInstallPromptHost, {
+ global: { plugins: [i18n, vuetify] },
+ });
+ await wrapper.vm.showNext();
+ await wrapper.vm.onPrimary();
+ expect(onPrimary).toHaveBeenCalled();
+ expect(wrapper.vm.visible).toBe(false);
+ expect(getSeenRevision("once")).toBe(3);
+ expect(await wrapper.vm.showNext()).toBe(false);
+ });
+
+ it("skips prompts when shouldShow returns false", async () => {
+ registerPostInstallPrompt({
+ id: "gated",
+ revision: 1,
+ titleKey: "post_install.demo_title",
+ shouldShow: () => false,
+ });
+ const wrapper = mount(PostInstallPromptHost, {
+ global: { plugins: [i18n, vuetify] },
+ });
+ expect(await wrapper.vm.showNext()).toBe(false);
+ });
+});
diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js
index bc50dcd8..ec993c2f 100644
--- a/tests/frontend/behaviorContracts.test.js
+++ b/tests/frontend/behaviorContracts.test.js
@@ -288,6 +288,12 @@ describe("behavior contracts: network visualiser performance", () => {
const engine = readSource("meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js");
expect(engine).toContain("meshchatxVisualiserSceneSet");
expect(engine).toContain("createVisualiserWebGLEngine");
+ expect(engine).toContain('pointerMode = "pinch"');
+ expect(engine).toContain("meshchatxVisualiserSceneZoomAt");
+ expect(engine).toContain("updateNodeImages");
+ const webgl = readSource("meshchatx/src/frontend/js/networkVisualiserWebGL.js");
+ expect(webgl).toContain("u_atlas");
+ expect(webgl).toContain("mergeSceneNodesWithTextures");
const prefs = readSource("meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js");
expect(prefs).toContain("persistVisualiserRenderer");
expect(prefs).toContain('"auto"');
diff --git a/tests/frontend/networkVisualiserWebGLEngine.test.js b/tests/frontend/networkVisualiserWebGLEngine.test.js
index 0edecdde..84f46c0b 100644
--- a/tests/frontend/networkVisualiserWebGLEngine.test.js
+++ b/tests/frontend/networkVisualiserWebGLEngine.test.js
@@ -5,7 +5,15 @@ import {
KIND_ME,
KIND_IFACE_ON,
KIND_PEER,
+ pointerDistance,
+ pointerMidpoint,
} from "@/js/networkVisualiserWebGLEngine.js";
+import {
+ atlasUvForSlot,
+ mergeSceneNodesWithTextures,
+ SCENE_NODE_STRIDE,
+ NODE_STRIDE,
+} from "@/js/networkVisualiserWebGL.js";
describe("networkVisualiserWebGLEngine", () => {
const sceneFns = [
@@ -67,4 +75,44 @@ describe("networkVisualiserWebGLEngine", () => {
expect(req.edges[0].from).toBe("me");
expect(req.width).toBe(640);
});
+
+ it("pointerDistance and midpoint support pinch zoom math", () => {
+ const a = { x: 0, y: 0 };
+ const b = { x: 30, y: 40 };
+ expect(pointerDistance(a, b)).toBe(50);
+ expect(pointerMidpoint(a, b)).toEqual({ x: 15, y: 20 });
+ });
+});
+
+describe("networkVisualiserWebGL textures", () => {
+ it("atlasUvForSlot maps grid cells", () => {
+ expect(atlasUvForSlot(0)).toEqual({ u: 0, v: 0 });
+ expect(atlasUvForSlot(1).u).toBeCloseTo(1 / 16);
+ expect(atlasUvForSlot(16).v).toBeCloseTo(1 / 16);
+ });
+
+ it("mergeSceneNodesWithTextures attaches atlas UVs", () => {
+ const scene = new Float32Array(SCENE_NODE_STRIDE);
+ scene[0] = 1;
+ scene[1] = 2;
+ scene[2] = 10;
+ scene[3] = 0.1;
+ scene[4] = 0.2;
+ scene[5] = 0.3;
+ scene[6] = 1;
+ scene[7] = 3;
+ const out = mergeSceneNodesWithTextures(scene, [{ useTex: 1, u: 0.25, v: 0.5 }]);
+ expect(out.length).toBe(NODE_STRIDE);
+ expect(out[0]).toBe(1);
+ expect(out[2]).toBe(10);
+ expect(out[7]).toBe(1);
+ expect(out[8]).toBe(0.25);
+ expect(out[9]).toBe(0.5);
+ });
+
+ it("mergeSceneNodesWithTextures falls back to untextured discs", () => {
+ const scene = new Float32Array(SCENE_NODE_STRIDE);
+ const out = mergeSceneNodesWithTextures(scene, [{ useTex: 0, u: 0, v: 0 }]);
+ expect(out[7]).toBe(0);
+ });
});
diff --git a/tests/frontend/registries.test.js b/tests/frontend/registries.test.js
index 8e31f6ac..1d8ef57e 100644
--- a/tests/frontend/registries.test.js
+++ b/tests/frontend/registries.test.js
@@ -25,6 +25,11 @@ import {
} from "../../meshchatx/src/frontend/js/registries/registerCoreContributions.js";
import { CORE_NAV_ENTRIES } from "../../meshchatx/src/frontend/js/registries/coreNavEntries.js";
import { CORE_TOOLS_ENTRIES } from "../../meshchatx/src/frontend/js/registries/coreToolsEntries.js";
+import {
+ postInstallPromptRegistry,
+ listPostInstallPrompts,
+} from "../../meshchatx/src/frontend/js/registries/postInstallPromptRegistry.js";
+import { CORE_POST_INSTALL_PROMPT_ENTRIES } from "../../meshchatx/src/frontend/js/registries/corePostInstallPromptEntries.js";
describe("registryCore", () => {
it("registers and lists entries", () => {
@@ -56,6 +61,7 @@ describe("contribution registries", () => {
toolsRegistry.clear();
commandRegistry.clear();
settingsSectionRegistry.clear();
+ postInstallPromptRegistry.clear();
});
it("registers nav items", () => {
@@ -108,6 +114,7 @@ describe("registerCoreContributions", () => {
toolsRegistry.clear();
commandRegistry.clear();
settingsSectionRegistry.clear();
+ postInstallPromptRegistry.clear();
});
it("loads all core entries once", () => {
@@ -115,6 +122,7 @@ describe("registerCoreContributions", () => {
registerCoreContributions();
expect(listNavItems()).toHaveLength(CORE_NAV_ENTRIES.length);
expect(listTools()).toHaveLength(CORE_TOOLS_ENTRIES.length);
+ expect(listPostInstallPrompts()).toHaveLength(CORE_POST_INSTALL_PROMPT_ENTRIES.length);
});
it("calls nav entry has a missed-calls pill badge", () => {
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────